mirror of
https://github.com/YuzuZensai/beamboxctl.git
synced 2026-07-21 20:42:19 +00:00
✨ feat: introduce platform-specific BLE backends
solves lack of Linux support in #2
This commit is contained in:
@@ -27,6 +27,7 @@
|
|||||||
"ink": "^6.6.0",
|
"ink": "^6.6.0",
|
||||||
"ink-big-text": "^2.0.0",
|
"ink-big-text": "^2.0.0",
|
||||||
"ink-spinner": "^5.0.0",
|
"ink-spinner": "^5.0.0",
|
||||||
|
"node-ble": "^1.13.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+952
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
export interface DiscoveredDevice {
|
||||||
|
address: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BleCharacteristic {
|
||||||
|
uuid: string;
|
||||||
|
write(data: Buffer, withoutResponse: boolean): Promise<void>;
|
||||||
|
subscribe(onData: (data: Buffer) => void): Promise<void>;
|
||||||
|
unsubscribe(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BleBackend {
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
init(): Promise<void>;
|
||||||
|
|
||||||
|
scanFor(
|
||||||
|
matcher: (device: DiscoveredDevice) => boolean,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<DiscoveredDevice | null>;
|
||||||
|
|
||||||
|
stopScan(): Promise<void>;
|
||||||
|
|
||||||
|
connect(address: string): Promise<void>;
|
||||||
|
|
||||||
|
onDisconnect(callback: () => void): void;
|
||||||
|
|
||||||
|
discoverCharacteristics(
|
||||||
|
writeUUID: string,
|
||||||
|
notifyUUID: string,
|
||||||
|
normalizeUUID: (uuid: string) => string,
|
||||||
|
): Promise<{ write: BleCharacteristic; notify: BleCharacteristic }>;
|
||||||
|
|
||||||
|
disconnect(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BleBackendType = "noble" | "dbus";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses dbus on Linux and noble on other platforms.
|
||||||
|
* You can override with BEAMBOXCTL_BLE_BACKEND=noble|dbus.
|
||||||
|
*/
|
||||||
|
export function resolveBackendType(): BleBackendType {
|
||||||
|
const override = process.env.BEAMBOXCTL_BLE_BACKEND?.toLowerCase().trim();
|
||||||
|
if (override === "noble" || override === "dbus") {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.platform === "linux" ? "dbus" : "noble";
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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 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();
|
||||||
|
if (await this.device.isConnected().catch(() => false)) {
|
||||||
|
await this.device.disconnect().catch(() => {});
|
||||||
|
}
|
||||||
|
this.device = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.destroy?.();
|
||||||
|
this.destroy = null;
|
||||||
|
this.bluetooth = null;
|
||||||
|
this.adapter = null;
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import noble from "@stoprocent/noble";
|
||||||
|
import type { Peripheral, Characteristic } from "@stoprocent/noble";
|
||||||
|
import { ConnectionError } from "../../utils/errors.ts";
|
||||||
|
import { logger } from "../../utils/logger.ts";
|
||||||
|
import type {
|
||||||
|
BleBackend,
|
||||||
|
BleCharacteristic,
|
||||||
|
DiscoveredDevice,
|
||||||
|
} from "../backend.ts";
|
||||||
|
|
||||||
|
class NobleCharacteristic implements BleCharacteristic {
|
||||||
|
constructor(private characteristic: Characteristic) {}
|
||||||
|
|
||||||
|
get uuid(): string {
|
||||||
|
return this.characteristic.uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(data: Buffer, withoutResponse: boolean): Promise<void> {
|
||||||
|
await this.characteristic.writeAsync(data, withoutResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
async subscribe(onData: (data: Buffer) => void): Promise<void> {
|
||||||
|
await this.characteristic.subscribeAsync();
|
||||||
|
this.characteristic.on("data", onData);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unsubscribe(): Promise<void> {
|
||||||
|
this.characteristic.removeAllListeners();
|
||||||
|
await this.characteristic.unsubscribeAsync().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NobleBackend implements BleBackend {
|
||||||
|
readonly name = "noble";
|
||||||
|
|
||||||
|
private peripheral: Peripheral | null = null;
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
if (this.initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
reject(new ConnectionError("Bluetooth adapter initialization timeout"));
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
const checkState = (state: string) => {
|
||||||
|
if (state === "poweredOn") {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
this.initialized = true;
|
||||||
|
resolve();
|
||||||
|
} else if (state === "poweredOff") {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new ConnectionError("Bluetooth adapter is not powered on"));
|
||||||
|
} else if (state === "unsupported") {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(
|
||||||
|
new ConnectionError("Bluetooth is not supported on this device"),
|
||||||
|
);
|
||||||
|
} else if (state === "unauthorized") {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new ConnectionError("Bluetooth access not authorized"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if ((noble as any).state === "poweredOn") {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
this.initialized = true;
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
noble.on("stateChange", checkState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async scanFor(
|
||||||
|
matcher: (device: DiscoveredDevice) => boolean,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<DiscoveredDevice | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(async () => {
|
||||||
|
if (!found) {
|
||||||
|
await this.stopScan();
|
||||||
|
noble.removeListener("discover", onDiscover);
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
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 = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
await this.stopScan();
|
||||||
|
noble.removeListener("discover", onDiscover);
|
||||||
|
this.peripheral = peripheral;
|
||||||
|
resolve(device);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
noble.on("discover", onDiscover);
|
||||||
|
|
||||||
|
noble.startScanningAsync([], false).catch((err: Error) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
noble.removeListener("discover", onDiscover);
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopScan(): Promise<void> {
|
||||||
|
await noble.stopScanningAsync().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(address: string): Promise<void> {
|
||||||
|
if (!this.peripheral) {
|
||||||
|
throw new ConnectionError(`No discovered peripheral for ${address}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.peripheral.state === "connected" ||
|
||||||
|
this.peripheral.state === "connecting"
|
||||||
|
) {
|
||||||
|
await this.peripheral.disconnectAsync().catch(() => {});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.peripheral.connectAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDisconnect(callback: () => void): void {
|
||||||
|
this.peripheral?.once("disconnect", callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async discoverCharacteristics(
|
||||||
|
writeUUID: string,
|
||||||
|
notifyUUID: string,
|
||||||
|
normalizeUUID: (uuid: string) => string,
|
||||||
|
): Promise<{ write: BleCharacteristic; notify: BleCharacteristic }> {
|
||||||
|
if (!this.peripheral) {
|
||||||
|
throw new ConnectionError("Not connected to device");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { services } =
|
||||||
|
await this.peripheral.discoverAllServicesAndCharacteristicsAsync();
|
||||||
|
|
||||||
|
const normalizedWriteUuid = normalizeUUID(writeUUID);
|
||||||
|
const normalizedNotifyUuid = normalizeUUID(notifyUUID);
|
||||||
|
|
||||||
|
let writeChar: Characteristic | null = null;
|
||||||
|
let notifyChar: Characteristic | null = null;
|
||||||
|
|
||||||
|
for (const service of services) {
|
||||||
|
for (const char of service.characteristics) {
|
||||||
|
const normalizedCharUuid = normalizeUUID(char.uuid);
|
||||||
|
if (normalizedCharUuid === normalizedWriteUuid) {
|
||||||
|
writeChar = char;
|
||||||
|
}
|
||||||
|
if (normalizedCharUuid === normalizedNotifyUuid) {
|
||||||
|
notifyChar = char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!writeChar || !notifyChar) {
|
||||||
|
throw new ConnectionError("Could not find required characteristics");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
write: new NobleCharacteristic(writeChar),
|
||||||
|
notify: new NobleCharacteristic(notifyChar),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (this.peripheral) {
|
||||||
|
this.peripheral.removeAllListeners();
|
||||||
|
await this.peripheral.disconnectAsync().catch(() => {});
|
||||||
|
this.peripheral = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
noble.removeAllListeners();
|
||||||
|
await noble.stopScanningAsync().catch(() => {});
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
-174
@@ -1,13 +1,20 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
EventEmitter.defaultMaxListeners = 20;
|
EventEmitter.defaultMaxListeners = 20;
|
||||||
|
|
||||||
import noble from "@stoprocent/noble";
|
|
||||||
import type { Peripheral, Characteristic } from "@stoprocent/noble";
|
|
||||||
import type { BLEConfig, ProtocolConfig } from "../protocol/index.ts";
|
import type { BLEConfig, ProtocolConfig } from "../protocol/index.ts";
|
||||||
import { PacketType } from "../protocol/index.ts";
|
import { PacketType } from "../protocol/index.ts";
|
||||||
import { DeviceNotFoundError, ConnectionError } from "../utils/errors.ts";
|
import { DeviceNotFoundError, ConnectionError } from "../utils/errors.ts";
|
||||||
import { PayloadBuilder, ResponseParser } from "../protocol/index.ts";
|
import { PayloadBuilder, ResponseParser } from "../protocol/index.ts";
|
||||||
import { logger, LogEventType } from "../utils/logger.ts";
|
import { logger, LogEventType } from "../utils/logger.ts";
|
||||||
|
import type { BleBackend, BleCharacteristic } from "./backend.ts";
|
||||||
|
import { resolveBackendType } from "./backend.ts";
|
||||||
|
import { NobleBackend } from "./backends/noble-backend.ts";
|
||||||
|
import { DBusBackend } from "./backends/dbus-backend.ts";
|
||||||
|
|
||||||
|
function createBackend(): BleBackend {
|
||||||
|
const type = resolveBackendType();
|
||||||
|
return type === "dbus" ? new DBusBackend() : new NobleBackend();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles notifications from the BeamBox device
|
* Handles notifications from the BeamBox device
|
||||||
@@ -214,14 +221,15 @@ class NotificationHandler {
|
|||||||
* Manages BLE connection and data transfer to BeamBox device
|
* Manages BLE connection and data transfer to BeamBox device
|
||||||
*/
|
*/
|
||||||
export class BleUploader {
|
export class BleUploader {
|
||||||
private peripheral: Peripheral | null = null;
|
private backend: BleBackend;
|
||||||
private writeCharacteristic: Characteristic | null = null;
|
private writeCharacteristic: BleCharacteristic | null = null;
|
||||||
private notifyCharacteristic: Characteristic | null = null;
|
private notifyCharacteristic: BleCharacteristic | null = null;
|
||||||
private notificationHandler: NotificationHandler;
|
private notificationHandler: NotificationHandler;
|
||||||
private payloadBuilder: PayloadBuilder;
|
private payloadBuilder: PayloadBuilder;
|
||||||
private chunkDelay: number;
|
private chunkDelay: number;
|
||||||
private verbose: boolean = false;
|
private verbose: boolean = false;
|
||||||
private isInitialized: boolean = false;
|
private isInitialized: boolean = false;
|
||||||
|
private connectedAddress: string | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private deviceAddress: string | null,
|
private deviceAddress: string | null,
|
||||||
@@ -234,50 +242,20 @@ export class BleUploader {
|
|||||||
this.verbose = verbose;
|
this.verbose = verbose;
|
||||||
this.notificationHandler = new NotificationHandler(verbose);
|
this.notificationHandler = new NotificationHandler(verbose);
|
||||||
this.payloadBuilder = new PayloadBuilder(protocolConfig);
|
this.payloadBuilder = new PayloadBuilder(protocolConfig);
|
||||||
|
this.backend = createBackend();
|
||||||
|
logger.debug(`Using BLE backend: ${this.backend.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize Bluetooth adapter and wait for powered on state
|
* Initialize Bluetooth adapter and wait for it to be ready
|
||||||
*/
|
*/
|
||||||
private async initBluetooth(): Promise<void> {
|
private async initBluetooth(): Promise<void> {
|
||||||
if (this.isInitialized) {
|
if (this.isInitialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
await this.backend.init();
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
reject(new ConnectionError("Bluetooth adapter initialization timeout"));
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
const checkState = (state: string) => {
|
|
||||||
if (state === "poweredOn") {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
resolve();
|
|
||||||
} else if (state === "poweredOff") {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
reject(new ConnectionError("Bluetooth adapter is not powered on"));
|
|
||||||
} else if (state === "unsupported") {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
reject(
|
|
||||||
new ConnectionError("Bluetooth is not supported on this device"),
|
|
||||||
);
|
|
||||||
} else if (state === "unauthorized") {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
reject(new ConnectionError("Bluetooth access not authorized"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check current state first
|
|
||||||
if ((noble as any).state === "poweredOn") {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
this.isInitialized = true;
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
noble.on("stateChange", checkState);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -315,50 +293,26 @@ export class BleUploader {
|
|||||||
|
|
||||||
logger.info("Starting device scan...", LogEventType.SCAN_START);
|
logger.info("Starting device scan...", LogEventType.SCAN_START);
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
const targetName = this.bleConfig.deviceName.toLowerCase();
|
||||||
const timeout = this.bleConfig.scanTimeout * 1000;
|
const timeout = this.bleConfig.scanTimeout * 1000;
|
||||||
let found = false;
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(async () => {
|
const found = await this.backend.scanFor((device) => {
|
||||||
if (!found) {
|
return (
|
||||||
await noble.stopScanningAsync();
|
!!device.name && device.name.toLowerCase().includes(targetName)
|
||||||
noble.removeListener("discover", onDiscover);
|
);
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
}, timeout);
|
}, timeout);
|
||||||
|
|
||||||
const onDiscover = async (peripheral: Peripheral) => {
|
if (!found) {
|
||||||
const name = peripheral.advertisement.localName;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
name &&
|
|
||||||
name.toLowerCase().includes(this.bleConfig.deviceName.toLowerCase())
|
|
||||||
) {
|
|
||||||
found = true;
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
await noble.stopScanningAsync();
|
|
||||||
noble.removeListener("discover", onDiscover);
|
|
||||||
|
|
||||||
const address = peripheral.address || peripheral.id;
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Found device: ${name} (${address})`,
|
`Found device: ${found.name} (${found.address})`,
|
||||||
LogEventType.DEVICE_FOUND,
|
LogEventType.DEVICE_FOUND,
|
||||||
{ name, address },
|
{ name: found.name, address: found.address },
|
||||||
);
|
);
|
||||||
|
|
||||||
this.peripheral = peripheral;
|
return found.address;
|
||||||
resolve(address);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
noble.on("discover", onDiscover);
|
|
||||||
|
|
||||||
noble.startScanningAsync([], false).catch((err: Error) => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
logger.error(`Scan error: ${err}`);
|
|
||||||
resolve(null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -369,7 +323,7 @@ export class BleUploader {
|
|||||||
try {
|
try {
|
||||||
await this.initBluetooth();
|
await this.initBluetooth();
|
||||||
|
|
||||||
if (!this.peripheral) {
|
if (!this.connectedAddress) {
|
||||||
if (!this.deviceAddress) {
|
if (!this.deviceAddress) {
|
||||||
logger.info("Scanning for device...", LogEventType.SCAN_START);
|
logger.info("Scanning for device...", LogEventType.SCAN_START);
|
||||||
const address = await this.findDevice();
|
const address = await this.findDevice();
|
||||||
@@ -385,74 +339,30 @@ export class BleUploader {
|
|||||||
LogEventType.SCAN_START,
|
LogEventType.SCAN_START,
|
||||||
);
|
);
|
||||||
|
|
||||||
const foundPeripheral = await new Promise<Peripheral | null>(
|
const targetAddress = this.deviceAddress.toLowerCase();
|
||||||
(resolve) => {
|
|
||||||
const timeout = this.bleConfig.scanTimeout * 1000;
|
const timeout = this.bleConfig.scanTimeout * 1000;
|
||||||
let found = false;
|
|
||||||
|
|
||||||
const timeoutId = setTimeout(async () => {
|
const found = await this.backend.scanFor(
|
||||||
if (!found) {
|
(device) => device.address.toLowerCase() === targetAddress,
|
||||||
await noble.stopScanningAsync();
|
timeout,
|
||||||
noble.removeListener("discover", onDiscover);
|
|
||||||
resolve(null);
|
|
||||||
}
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
const onDiscover = async (peripheral: Peripheral) => {
|
|
||||||
const address = peripheral.address || peripheral.id;
|
|
||||||
if (
|
|
||||||
address.toLowerCase() === this.deviceAddress?.toLowerCase()
|
|
||||||
) {
|
|
||||||
found = true;
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
await noble.stopScanningAsync();
|
|
||||||
noble.removeListener("discover", onDiscover);
|
|
||||||
resolve(peripheral);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
noble.on("discover", onDiscover);
|
|
||||||
noble.startScanningAsync([], false).catch(() => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
resolve(null);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!foundPeripheral) {
|
if (!found) {
|
||||||
throw new DeviceNotFoundError(
|
throw new DeviceNotFoundError(
|
||||||
`Could not find device with address '${this.deviceAddress}'`,
|
`Could not find device with address '${this.deviceAddress}'`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.peripheral = foundPeripheral;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop scanning before connecting
|
await this.backend.stopScan();
|
||||||
await noble.stopScanningAsync().catch(() => {});
|
|
||||||
|
|
||||||
// Check peripheral state before connecting
|
|
||||||
logger.debug(
|
|
||||||
`Peripheral state before connect: ${this.peripheral!.state}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// If already connected, disconnect first to ensure clean state
|
|
||||||
if (
|
|
||||||
this.peripheral!.state === "connected" ||
|
|
||||||
this.peripheral!.state === "connecting"
|
|
||||||
) {
|
|
||||||
logger.debug(
|
|
||||||
"Device already connected/connecting, disconnecting first...",
|
|
||||||
);
|
|
||||||
await this.peripheral!.disconnectAsync().catch(() => {});
|
|
||||||
await this.sleep(500); // Brief delay after disconnect
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to device
|
// Connect to device
|
||||||
logger.info("Connecting to device...", LogEventType.CONNECT_START);
|
logger.info("Connecting to device...", LogEventType.CONNECT_START);
|
||||||
const connectStartTime = Date.now();
|
const connectStartTime = Date.now();
|
||||||
|
|
||||||
await this.peripheral!.connectAsync();
|
await this.backend.connect(this.deviceAddress!);
|
||||||
|
this.connectedAddress = this.deviceAddress;
|
||||||
|
|
||||||
const connectDuration = Date.now() - connectStartTime;
|
const connectDuration = Date.now() - connectStartTime;
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -461,9 +371,9 @@ export class BleUploader {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Handle disconnect events
|
// Handle disconnect events
|
||||||
this.peripheral!.once("disconnect", () => {
|
this.backend.onDisconnect(() => {
|
||||||
logger.info("Device disconnected");
|
logger.info("Device disconnected");
|
||||||
this.peripheral = null;
|
this.connectedAddress = null;
|
||||||
this.writeCharacteristic = null;
|
this.writeCharacteristic = null;
|
||||||
this.notifyCharacteristic = null;
|
this.notifyCharacteristic = null;
|
||||||
});
|
});
|
||||||
@@ -473,10 +383,7 @@ export class BleUploader {
|
|||||||
|
|
||||||
// Setup notifications
|
// Setup notifications
|
||||||
if (this.notifyCharacteristic) {
|
if (this.notifyCharacteristic) {
|
||||||
await this.notifyCharacteristic.subscribeAsync();
|
await this.notifyCharacteristic.subscribe((data: Buffer) => {
|
||||||
|
|
||||||
// Listen for notifications
|
|
||||||
this.notifyCharacteristic.on("data", (data: Buffer) => {
|
|
||||||
this.notificationHandler.handleNotification(data);
|
this.notificationHandler.handleNotification(data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -509,13 +416,6 @@ export class BleUploader {
|
|||||||
* Discover required characteristics on the device
|
* Discover required characteristics on the device
|
||||||
*/
|
*/
|
||||||
private async discoverCharacteristics(): Promise<void> {
|
private async discoverCharacteristics(): Promise<void> {
|
||||||
if (!this.peripheral) {
|
|
||||||
throw new ConnectionError("Not connected to device");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { services } =
|
|
||||||
await this.peripheral.discoverAllServicesAndCharacteristicsAsync();
|
|
||||||
|
|
||||||
const normalizedWriteUuid = this.normalizeUUID(
|
const normalizedWriteUuid = this.normalizeUUID(
|
||||||
this.bleConfig.writeCharacteristicUUID,
|
this.bleConfig.writeCharacteristicUUID,
|
||||||
);
|
);
|
||||||
@@ -526,39 +426,26 @@ export class BleUploader {
|
|||||||
logger.debug(`Looking for write UUID: ${normalizedWriteUuid}`);
|
logger.debug(`Looking for write UUID: ${normalizedWriteUuid}`);
|
||||||
logger.debug(`Looking for notify UUID: ${normalizedNotifyUuid}`);
|
logger.debug(`Looking for notify UUID: ${normalizedNotifyUuid}`);
|
||||||
|
|
||||||
// Iterate through services and their characteristics
|
const { write, notify } = await this.backend.discoverCharacteristics(
|
||||||
for (const service of services) {
|
this.bleConfig.writeCharacteristicUUID,
|
||||||
logger.debug(`Service: ${service.uuid}`);
|
this.bleConfig.notifyCharacteristicUUID,
|
||||||
|
(uuid) => this.normalizeUUID(uuid),
|
||||||
for (const char of service.characteristics) {
|
|
||||||
const normalizedCharUuid = this.normalizeUUID(char.uuid);
|
|
||||||
logger.debug(
|
|
||||||
` Characteristic: ${char.uuid} (normalized: ${normalizedCharUuid})`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (normalizedCharUuid === normalizedWriteUuid) {
|
this.writeCharacteristic = write;
|
||||||
this.writeCharacteristic = char;
|
this.notifyCharacteristic = notify;
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Found write characteristic: ${char.uuid}`,
|
`Found write characteristic: ${write.uuid}`,
|
||||||
LogEventType.DISCOVER_CHAR,
|
LogEventType.DISCOVER_CHAR,
|
||||||
{ type: "write", uuid: char.uuid },
|
{ type: "write", uuid: write.uuid },
|
||||||
);
|
);
|
||||||
}
|
|
||||||
if (normalizedCharUuid === normalizedNotifyUuid) {
|
|
||||||
this.notifyCharacteristic = char;
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Found notify characteristic: ${char.uuid}`,
|
`Found notify characteristic: ${notify.uuid}`,
|
||||||
LogEventType.DISCOVER_CHAR,
|
LogEventType.DISCOVER_CHAR,
|
||||||
{ type: "notify", uuid: char.uuid },
|
{ type: "notify", uuid: notify.uuid },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.writeCharacteristic || !this.notifyCharacteristic) {
|
|
||||||
throw new ConnectionError("Could not find required characteristics");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disconnect from the device
|
* Disconnect from the device
|
||||||
@@ -566,16 +453,13 @@ export class BleUploader {
|
|||||||
public async disconnect(): Promise<void> {
|
public async disconnect(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (this.notifyCharacteristic) {
|
if (this.notifyCharacteristic) {
|
||||||
this.notifyCharacteristic.removeAllListeners();
|
await this.notifyCharacteristic.unsubscribe();
|
||||||
await this.notifyCharacteristic.unsubscribeAsync().catch(() => {});
|
|
||||||
}
|
|
||||||
if (this.peripheral) {
|
|
||||||
this.peripheral.removeAllListeners();
|
|
||||||
await this.peripheral.disconnectAsync().catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
noble.removeAllListeners();
|
await this.backend.disconnect();
|
||||||
await noble.stopScanningAsync().catch(() => {});
|
this.connectedAddress = null;
|
||||||
|
this.writeCharacteristic = null;
|
||||||
|
this.notifyCharacteristic = null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warning(`Error during disconnect: ${error}`);
|
logger.warning(`Error during disconnect: ${error}`);
|
||||||
}
|
}
|
||||||
@@ -618,7 +502,7 @@ export class BleUploader {
|
|||||||
this.notificationHandler.logSentPacket(packet, "Image info packet");
|
this.notificationHandler.logSentPacket(packet, "Image info packet");
|
||||||
|
|
||||||
// Write without response
|
// Write without response
|
||||||
await this.writeCharacteristic.writeAsync(packet, true);
|
await this.writeCharacteristic.write(packet, true);
|
||||||
await this.sleep(this.protocolConfig.imageInfoDelay * 1000);
|
await this.sleep(this.protocolConfig.imageInfoDelay * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -696,7 +580,7 @@ export class BleUploader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write without response
|
// Write without response
|
||||||
await this.writeCharacteristic.writeAsync(packet, true);
|
await this.writeCharacteristic.write(packet, true);
|
||||||
|
|
||||||
if (this.notificationHandler.errorFlag) {
|
if (this.notificationHandler.errorFlag) {
|
||||||
logger.error("Device error flag set; aborting send.");
|
logger.error("Device error flag set; aborting send.");
|
||||||
|
|||||||
Reference in New Issue
Block a user