1
0
Fork 0
mirror of https://github.com/betaflight/betaflight-configurator.git synced 2025-07-23 00:05:22 +03:00
betaflight-configurator/src/js/protocols/WebBluetooth.js
Mark Haslinghuis 4aad8c648b
Add serial facade (#4402)
* Add serial facade

* Fix websocket connection

* Refactor

* Fix unplug

* Fix reboot / unplug reconnect issue

* The real deal (detail has no value)
2025-03-30 21:42:03 +02:00

354 lines
12 KiB
JavaScript

import { i18n } from "../localization";
import { gui_log } from "../gui_log";
import { bluetoothDevices } from "./devices";
/* Certain flags needs to be enabled in the browser to use BT
*
* app.commandLine.appendSwitch('enable-web-bluetooth', "true");
* app.commandLine.appendSwitch('disable-hid-blocklist')
* app.commandLine.appendSwitch('enable-experimental-web-platform-features');
*
*/
class WebBluetooth extends EventTarget {
constructor() {
super();
this.connected = false;
this.openRequested = false;
this.openCanceled = false;
this.closeRequested = false;
this.transmitting = false;
this.connectionInfo = null;
this.bitrate = 0;
this.bytesSent = 0;
this.bytesReceived = 0;
this.failed = 0;
this.portCounter = 0;
this.devices = [];
this.device = null;
this.logHead = "[BLUETOOTH]";
if (!this.bluetooth && window && window.navigator && window.navigator.bluetooth) {
this.bluetooth = navigator.bluetooth;
} else {
console.error(`${this.logHead} Bluetooth API not available`);
return;
}
this.connect = this.connect.bind(this);
this.bluetooth.addEventListener("connect", (e) => this.handleNewDevice(e.target));
this.bluetooth.addEventListener("disconnect", (e) => this.handleRemovedDevice(e.target));
this.bluetooth.addEventListener("gatserverdisconnected", (e) => this.handleRemovedDevice(e.target));
this.loadDevices();
}
handleNewDevice(device) {
const added = this.createPort(device);
this.devices.push(added);
this.dispatchEvent(new CustomEvent("addedDevice", { detail: added }));
return added;
}
handleRemovedDevice(device) {
const removed = this.devices.find((port) => port.port === device);
this.devices = this.devices.filter((port) => port.port !== device);
this.dispatchEvent(new CustomEvent("removedDevice", { detail: removed }));
}
handleReceiveBytes(info) {
this.bytesReceived += info.detail.byteLength;
}
handleDisconnect() {
this.disconnect();
this.closeRequested = true;
}
getConnectedPort() {
return this.device;
}
createPort(device) {
return {
path: `bluetooth_${this.portCounter++}`,
displayName: device.name,
vendorId: "unknown",
productId: device.id,
port: device,
};
}
async loadDevices() {
const devices = await this.getDevices();
this.portCounter = 1;
this.devices = devices.map((device) => this.createPort(device));
}
async requestPermissionDevice() {
let newPermissionPort = null;
const uuids = [];
bluetoothDevices.forEach((device) => {
uuids.push(device.serviceUuid);
});
const options = { acceptAllDevices: true, optionalServices: uuids };
try {
const userSelectedPort = await this.bluetooth.requestDevice(options);
newPermissionPort = this.devices.find((port) => port.port === userSelectedPort);
if (!newPermissionPort) {
newPermissionPort = this.handleNewDevice(userSelectedPort);
}
console.info(`${this.logHead} User selected Bluetooth device from permissions:`, newPermissionPort.path);
} catch (error) {
console.error(`${this.logHead} User didn't select any Bluetooth device when requesting permission:`, error);
}
return newPermissionPort;
}
async getDevices() {
return this.devices;
}
getAvailability() {
this.bluetooth.getAvailability().then((available) => {
console.log(`${this.logHead} Bluetooth available:`, available);
this.available = available;
return available;
});
}
async connect(path, options) {
this.openRequested = true;
this.closeRequested = false;
this.device = this.devices.find((device) => device.path === path).port;
console.log(`${this.logHead} Opening connection with ID: ${path}, Baud: ${options.baudRate}`);
this.device.addEventListener("gattserverdisconnected", this.handleDisconnect.bind(this));
try {
console.log(`${this.logHead} Connecting to GATT Server`);
await this.gattConnect();
gui_log(i18n.getMessage("bluetoothConnected", [this.device.name]));
await this.getServices();
await this.getCharacteristics();
await this.startNotifications();
} catch (error) {
gui_log(i18n.getMessage("bluetoothConnectionError", [error]));
}
// Bluetooth API doesn't provide a way for getInfo() or similar to get the connection info
const connectionInfo = this.device.gatt.connected;
if (connectionInfo && !this.openCanceled) {
this.connected = true;
this.connectionId = this.device.port;
this.bitrate = options.baudRate;
this.bytesReceived = 0;
this.bytesSent = 0;
this.failed = 0;
this.openRequested = false;
this.device.addEventListener("disconnect", this.handleDisconnect.bind(this));
this.addEventListener("receive", this.handleReceiveBytes);
console.log(`${this.logHead} Connection opened with ID: ${this.connectionId}, Baud: ${options.baudRate}`);
this.dispatchEvent(new CustomEvent("connect", { detail: connectionInfo }));
} else if (connectionInfo && this.openCanceled) {
this.connectionId = this.device.port;
console.log(
`${this.logHead} Connection opened with ID: ${connectionInfo.connectionId}, but request was canceled, disconnecting`,
);
// some bluetooth dongles/dongle drivers really doesn't like to be closed instantly, adding a small delay
setTimeout(() => {
this.openRequested = false;
this.openCanceled = false;
this.disconnect(() => {
this.dispatchEvent(new CustomEvent("connect", { detail: false }));
});
}, 150);
} else if (this.openCanceled) {
console.log(`${this.logHead} Connection didn't open and request was canceled`);
this.openRequested = false;
this.openCanceled = false;
this.dispatchEvent(new CustomEvent("connect", { detail: false }));
} else {
this.openRequested = false;
console.log(`${this.logHead} Failed to open bluetooth port`);
this.dispatchEvent(new CustomEvent("connect", { detail: false }));
}
}
async gattConnect() {
this.server = await this.device.gatt?.connect();
}
async getServices() {
console.log(`${this.logHead} Get primary services`);
this.services = await this.server.getPrimaryServices();
this.service = this.services.find((service) => {
this.deviceDescription = bluetoothDevices.find((device) => device.serviceUuid == service.uuid);
return this.deviceDescription;
});
if (!this.deviceDescription) {
throw new Error("Unsupported device");
}
gui_log(i18n.getMessage("bluetoothConnectionType", [this.deviceDescription.name]));
console.log(`${this.logHead} Connected to service:`, this.service.uuid);
return this.service;
}
async getCharacteristics() {
const characteristics = await this.service.getCharacteristics();
characteristics.forEach((characteristic) => {
// console.log("Characteristic: ", characteristic);
if (characteristic.uuid == this.deviceDescription.writeCharacteristic) {
this.writeCharacteristic = characteristic;
}
if (characteristic.uuid == this.deviceDescription.readCharacteristic) {
this.readCharacteristic = characteristic;
}
return this.writeCharacteristic && this.readCharacteristic;
});
if (!this.writeCharacteristic) {
throw new Error(
"Unexpected write characteristic found - should be",
this.deviceDescription.writeCharacteristic,
);
}
if (!this.readCharacteristic) {
throw new Error(
"Unexpected read characteristic found - should be",
this.deviceDescription.readCharacteristic,
);
}
this.readCharacteristic.addEventListener("characteristicvaluechanged", this.handleNotification.bind(this));
return await this.readCharacteristic.readValue();
}
handleNotification(event) {
const buffer = new Uint8Array(event.target.value.byteLength);
for (let i = 0; i < event.target.value.byteLength; i++) {
buffer[i] = event.target.value.getUint8(i);
}
this.dispatchEvent(new CustomEvent("receive", { detail: buffer }));
}
startNotifications() {
if (!this.readCharacteristic) {
throw new Error("No read characteristic");
}
if (!this.readCharacteristic.properties.notify) {
throw new Error("Read characteristic unable to notify.");
}
return this.readCharacteristic.startNotifications();
}
async disconnect() {
this.connected = false;
this.transmitting = false;
this.bytesReceived = 0;
this.bytesSent = 0;
// if we are already closing, don't do it again
if (this.closeRequested) {
return;
}
const doCleanup = async () => {
this.removeEventListener("receive", this.handleReceiveBytes);
if (this.device) {
this.device.removeEventListener("disconnect", this.handleDisconnect.bind(this));
this.device.removeEventListener("gattserverdisconnected", this.handleDisconnect);
this.readCharacteristic.removeEventListener(
"characteristicvaluechanged",
this.handleNotification.bind(this),
);
if (this.device.gatt.connected) {
this.device.gatt.disconnect();
}
this.writeCharacteristic = false;
this.readCharacteristic = false;
this.deviceDescription = false;
this.device = null;
}
};
try {
await doCleanup();
console.log(
`${this.logHead} Connection with ID: ${this.connectionId} closed, Sent: ${this.bytesSent} bytes, Received: ${this.bytesReceived} bytes`,
);
this.connectionId = false;
this.bitrate = 0;
this.dispatchEvent(new CustomEvent("disconnect", { detail: true }));
} catch (error) {
console.error(error);
console.error(
`${this.logHead} Failed to close connection with ID: ${this.connectionId} closed, Sent: ${this.bytesSent} bytes, Received: ${this.bytesReceived} bytes`,
);
this.dispatchEvent(new CustomEvent("disconnect", { detail: false }));
} finally {
if (this.openCanceled) {
this.openCanceled = false;
}
}
}
async send(data) {
if (!this.writeCharacteristic) {
return;
}
// There is no writable stream in the bluetooth API
this.bytesSent += data.byteLength;
const dataBuffer = new Uint8Array(data);
await this.writeCharacteristic.writeValue(dataBuffer);
return {
bytesSent: data.byteLength,
resultCode: 0,
};
}
}
export default WebBluetooth;