1
0
Fork 0
mirror of https://github.com/betaflight/betaflight-configurator.git synced 2025-07-21 15:25:22 +03:00

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)
This commit is contained in:
Mark Haslinghuis 2025-03-30 21:42:03 +02:00 committed by GitHub
parent 96a82d77f0
commit 4aad8c648b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 839 additions and 440 deletions

View file

@ -1,8 +1,6 @@
import GUI from "./gui.js";
import CONFIGURATOR from "./data_storage.js";
import { serialShim } from "./serial_shim.js";
let serial = serialShim();
import { serial } from "./serial.js";
const MSP = {
symbols: {
@ -370,17 +368,12 @@ const MSP = {
return bufferOut;
},
send_cli_command(str, callback) {
serial = serialShim();
const bufferOut = this.encode_message_cli(str);
this.cli_callback = callback;
serial.send(bufferOut);
},
send_message(code, data, callback_sent, callback_msp, doCallbackOnError) {
// Hack to make BT work
serial = serialShim();
const connected = serial.connected;
if (code === undefined || !connected || CONFIGURATOR.virtualMode) {

View file

@ -4,18 +4,17 @@ import { i18n } from "../localization";
import GUI from "../gui";
import MSP from "../msp";
import FC from "../fc";
import { serialShim } from "../serial_shim";
import { serial } from "../serial";
import MSPCodes from "./MSPCodes";
import CONFIGURATOR from "../data_storage";
import { gui_log } from "../gui_log";
const serial = serialShim();
/**
* This seems to be mainly used in firmware flasher parts.
*/
function readSerialAdapter(e) {
read_serial(e.detail.buffer);
read_serial(e.detail);
}
function disconnectAndCleanup() {

View file

@ -1,8 +1,8 @@
import { get as getConfig } from "./ConfigStorage";
import { EventBus } from "../components/eventBus";
import serial from "./webSerial";
import usb from "./protocols/webusbdfu";
import BT from "./protocols/bluetooth";
import { serial } from "./serial.js";
import WEBUSBDFU from "./protocols/webusbdfu";
import WebBluetooth from "./protocols/WebBluetooth.js";
import { reactive } from "vue";
const DEFAULT_PORT = "noselection";
@ -39,16 +39,39 @@ PortHandler.initialize = function () {
EventBus.$on("ports-input:request-permission", this.askSerialPermissionPort.bind(this));
EventBus.$on("ports-input:change", this.onChangeSelectedPort.bind(this));
BT.addEventListener("addedDevice", (event) => this.addedBluetoothDevice(event.detail));
BT.addEventListener("removedDevice", (event) => this.addedBluetoothDevice(event.detail));
// Use serial for all protocol events
serial.addEventListener("addedDevice", (event) => {
const detail = event.detail;
serial.addEventListener("addedDevice", (event) => this.addedSerialDevice(event.detail));
serial.addEventListener("removedDevice", (event) => this.removedSerialDevice(event.detail));
// Determine the device type based on its properties
if (detail?.path?.startsWith("bluetooth")) {
this.addedBluetoothDevice(detail);
} else if (detail?.path?.startsWith("usb_")) {
this.addedUsbDevice(detail);
} else {
this.addedSerialDevice(detail);
}
});
usb.addEventListener("addedDevice", (event) => this.addedUsbDevice(event.detail));
serial.addEventListener("removedDevice", (event) => {
const detail = event.detail;
this.addedBluetoothDevice();
// Determine the device type based on its properties
if (detail?.path?.startsWith("bluetooth")) {
this.removedBluetoothDevice(detail);
} else if (detail?.path?.startsWith("usb_")) {
// Handle USB device removal if needed
} else {
this.removedSerialDevice(detail);
}
});
// Keep USB listener separate as it's not part of the serial protocols
WEBUSBDFU.addEventListener("addedDevice", (event) => this.addedUsbDevice(event.detail));
// Initial device discovery
this.addedSerialDevice();
this.addedBluetoothDevice();
this.addedUsbDevice();
};
@ -125,7 +148,7 @@ PortHandler.updateCurrentSerialPortsList = async function () {
};
PortHandler.updateCurrentUsbPortsList = async function () {
const ports = await usb.getDevices();
const ports = await WEBUSBDFU.getDevices();
const orderedPorts = this.sortPorts(ports);
this.dfuAvailable = orderedPorts.length > 0;
console.log(`${this.logHead} Found DFU port`, orderedPorts);
@ -133,8 +156,8 @@ PortHandler.updateCurrentUsbPortsList = async function () {
};
PortHandler.updateCurrentBluetoothPortsList = async function () {
if (BT.bluetooth) {
const ports = await BT.getDevices();
if (WebBluetooth.bluetooth) {
const ports = await WebBluetooth.getDevices();
const orderedPorts = this.sortPorts(ports);
this.bluetoothAvailable = orderedPorts.length > 0;
console.log(`${this.logHead} Found bluetooth port`, orderedPorts);
@ -152,8 +175,8 @@ PortHandler.sortPorts = function (ports) {
};
PortHandler.askBluetoothPermissionPort = function () {
if (BT.bluetooth) {
BT.requestPermissionDevice().then((port) => {
if (WebBluetooth.bluetooth) {
WebBluetooth.requestPermissionDevice().then((port) => {
// When giving permission to a new device, the port is selected in the handleNewDevice method, but if the user
// selects a device that had already permission, or cancels the permission request, we need to select the port
// so do it here too
@ -180,14 +203,14 @@ PortHandler.selectActivePort = function (suggestedDevice) {
selectedPort = this.currentSerialPorts.find((device) => device === serial.getConnectedPort());
}
// Return the same that is connected to usb (dfu mode)
if (usb.usbDevice) {
selectedPort = this.currentUsbPorts.find((device) => device === usb.getConnectedPort());
// Return the same that is connected to WEBUSBDFU (dfu mode)
if (WEBUSBDFU.usbDevice) {
selectedPort = this.currentUsbPorts.find((device) => device === WEBUSBDFU.getConnectedPort());
}
// Return the same that is connected to bluetooth
if (BT.device) {
selectedPort = this.currentBluetoothPorts.find((device) => device === BT.getConnectedPort());
if (WebBluetooth.device) {
selectedPort = this.currentBluetoothPorts.find((device) => device === WebBluetooth.getConnectedPort());
}
// Return the suggested device (the new device that has been detected)

View file

@ -1,6 +1,4 @@
import { serialShim } from "./serial_shim";
const serial = serialShim();
import { serial } from "./serial";
const PortUsage = {
previous_received: 0,

View file

@ -1,7 +1,7 @@
const VIRTUAL = "virtual";
/**
* Stripped down version of our nwjs based serial port implementation
* Stripped down version of previous nwjs based serial port implementation
* which is required to still have virtual serial port support in the
* browser.
*/
@ -38,6 +38,14 @@ class VirtualSerial {
}
}
}
getConnectedPort() {
return this.connectionId;
}
getDevices() {
return new Promise((resolve) => {
resolve([{ path: VIRTUAL }]);
});
}
}
export default new VirtualSerial();
export default VirtualSerial;

View file

@ -1,5 +1,6 @@
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
*
@ -9,64 +10,10 @@ import { gui_log } from "../gui_log";
*
*/
const bluetoothDevices = [
{
name: "CC2541",
serviceUuid: "0000ffe0-0000-1000-8000-00805f9b34fb",
writeCharacteristic: "0000ffe1-0000-1000-8000-00805f9b34fb",
readCharacteristic: "0000ffe1-0000-1000-8000-00805f9b34fb",
},
{
name: "HC-05",
serviceUuid: "00001101-0000-1000-8000-00805f9b34fb",
writeCharacteristic: "00001101-0000-1000-8000-00805f9b34fb",
readCharacteristic: "00001101-0000-1000-8000-00805f9b34fb",
},
{
name: "HM-10",
serviceUuid: "0000ffe1-0000-1000-8000-00805f9b34fb",
writeCharacteristic: "0000ffe1-0000-1000-8000-00805f9b34fb",
readCharacteristic: "0000ffe1-0000-1000-8000-00805f9b34fb",
},
{
name: "HM-11",
serviceUuid: "6e400001-b5a3-f393-e0a9-e50e24dcca9e",
writeCharacteristic: "6e400003-b5a3-f393-e0a9-e50e24dcca9e",
readCharacteristic: "6e400002-b5a3-f393-e0a9-e50e24dcca9e",
},
{
name: "Nordic NRF",
serviceUuid: "6e400001-b5a3-f393-e0a9-e50e24dcca9e",
writeCharacteristic: "6e400003-b5a3-f393-e0a9-e50e24dcca9e",
readCharacteristic: "6e400002-b5a3-f393-e0a9-e50e24dcca9e",
},
{
name: "SpeedyBee V1",
serviceUuid: "00001000-0000-1000-8000-00805f9b34fb",
writeCharacteristic: "00001001-0000-1000-8000-00805f9b34fb",
readCharacteristic: "00001002-0000-1000-8000-00805f9b34fb",
},
{
name: "SpeedyBee V2",
serviceUuid: "0000abf0-0000-1000-8000-00805f9b34fb",
writeCharacteristic: "0000abf1-0000-1000-8000-00805f9b34fb",
readCharacteristic: "0000abf2-0000-1000-8000-00805f9b34fb",
},
];
class BT extends EventTarget {
class WebBluetooth extends EventTarget {
constructor() {
super();
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.connected = false;
this.openRequested = false;
this.openCanceled = false;
@ -83,6 +30,15 @@ class BT extends EventTarget {
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));
@ -395,4 +351,4 @@ class BT extends EventTarget {
}
}
export default new BT();
export default WebBluetooth;

View file

@ -0,0 +1,372 @@
import { webSerialDevices, vendorIdNames } from "./devices";
import { checkBrowserCompatibility } from "../utils/checkBrowserCompatibilty";
async function* streamAsyncIterable(reader, keepReadingFlag) {
try {
while (keepReadingFlag()) {
const { done, value } = await reader.read();
if (done) {
return;
}
yield value;
}
} finally {
reader.releaseLock();
}
}
/**
* WebSerial protocol implementation for the Serial base class
*/
class WebSerial extends EventTarget {
constructor() {
super();
checkBrowserCompatibility();
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.logHead = "[SERIAL]";
this.portCounter = 0;
this.ports = [];
this.port = null;
this.reader = null;
this.writer = null;
this.reading = false;
this.connect = this.connect.bind(this);
this.disconnect = this.disconnect.bind(this);
this.handleDisconnect = this.handleDisconnect.bind(this);
this.handleReceiveBytes = this.handleReceiveBytes.bind(this);
// Initialize device connection/disconnection listeners
if (navigator.serial) {
navigator.serial.addEventListener("connect", (e) => this.handleNewDevice(e.target));
navigator.serial.addEventListener("disconnect", (e) => this.handleRemovedDevice(e.target));
}
this.loadDevices();
}
handleNewDevice(device) {
const added = this.createPort(device);
this.ports.push(added);
this.dispatchEvent(new CustomEvent("addedDevice", { detail: added }));
return added;
}
handleRemovedDevice(device) {
const removed = this.ports.find((port) => port.port === device);
this.ports = this.ports.filter((port) => port.port !== device);
this.dispatchEvent(new CustomEvent("removedDevice", { detail: removed }));
}
handleReceiveBytes(info) {
this.bytesReceived += info.detail.byteLength;
}
handleDisconnect() {
console.log(`${this.logHead} Device disconnected externally`);
this.disconnect();
}
getConnectedPort() {
return this.port;
}
createPort(port) {
const portInfo = port.getInfo();
const displayName = vendorIdNames[portInfo.usbVendorId]
? vendorIdNames[portInfo.usbVendorId]
: `VID:${portInfo.usbVendorId} PID:${portInfo.usbProductId}`;
return {
path: `serial_${this.portCounter++}`,
displayName: `Betaflight ${displayName}`,
vendorId: portInfo.usbVendorId,
productId: portInfo.usbProductId,
port: port,
};
}
async loadDevices() {
if (!navigator.serial) {
console.error(`${this.logHead} Web Serial API not available`);
return;
}
try {
const ports = await navigator.serial.getPorts();
this.portCounter = 1;
this.ports = ports.map((port) => this.createPort(port));
} catch (error) {
console.error(`${this.logHead} Error loading devices:`, error);
}
}
async requestPermissionDevice(showAllSerialDevices = false) {
if (!navigator.serial) {
console.error(`${this.logHead} Web Serial API not available`);
return null;
}
let newPermissionPort = null;
try {
const options = showAllSerialDevices ? {} : { filters: webSerialDevices };
const userSelectedPort = await navigator.serial.requestPort(options);
newPermissionPort = this.ports.find((port) => port.port === userSelectedPort);
if (!newPermissionPort) {
newPermissionPort = this.handleNewDevice(userSelectedPort);
}
console.info(`${this.logHead} User selected SERIAL device from permissions:`, newPermissionPort.path);
} catch (error) {
console.error(`${this.logHead} User didn't select any SERIAL device when requesting permission:`, error);
}
return newPermissionPort;
}
async getDevices() {
return this.ports;
}
async connect(path, options = { baudRate: 115200 }) {
// Prevent double connections
if (this.connected) {
console.log(`${this.logHead} Already connected, not connecting again`);
return true;
}
this.openRequested = true;
this.closeRequested = false;
try {
const device = this.ports.find((device) => device.path === path);
if (!device) {
console.error(`${this.logHead} Device not found:`, path);
this.dispatchEvent(new CustomEvent("connect", { detail: false }));
return false;
}
this.port = device.port;
await this.port.open(options);
const connectionInfo = this.port.getInfo();
this.connectionInfo = connectionInfo;
this.writer = this.port.writable.getWriter();
this.reader = this.port.readable.getReader();
if (connectionInfo && !this.openCanceled) {
this.connected = true;
this.connectionId = path;
this.bitrate = options.baudRate;
this.bytesReceived = 0;
this.bytesSent = 0;
this.failed = 0;
this.openRequested = false;
this.port.addEventListener("disconnect", this.handleDisconnect);
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 }));
// Start reading from the port
this.reading = true;
this.readLoop();
return true;
} else if (connectionInfo && this.openCanceled) {
this.connectionId = path;
console.log(
`${this.logHead} Connection opened with ID: ${path}, 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);
return false;
} else {
this.openRequested = false;
console.log(`${this.logHead} Failed to open serial port`);
this.dispatchEvent(new CustomEvent("connect", { detail: false }));
return false;
}
} catch (error) {
console.error(`${this.logHead} Error connecting:`, error);
this.openRequested = false;
this.dispatchEvent(new CustomEvent("connect", { detail: false }));
return false;
}
}
async readLoop() {
try {
for await (let value of streamAsyncIterable(this.reader, () => this.reading)) {
this.dispatchEvent(new CustomEvent("receive", { detail: value }));
}
} catch (error) {
console.error(`${this.logHead} Error reading:`, error);
if (this.connected) {
this.disconnect();
}
}
}
async disconnect(callback) {
// If already disconnected, just call callback and return
if (!this.connected) {
if (callback) {
try {
callback(true);
} catch (error) {
console.error(`${this.logHead} Error calling disconnect callback:`, error);
}
}
return true;
}
// Mark as disconnected immediately to prevent race conditions
this.connected = false;
this.transmitting = false;
this.reading = false;
// if we are already closing, don't do it again
if (this.closeRequested) {
if (callback) {
try {
callback(true);
} catch (error) {
console.error(`${this.logHead} Error calling disconnect callback:`, error);
}
}
return true;
}
this.closeRequested = true;
try {
this.removeEventListener("receive", this.handleReceiveBytes);
if (this.reader) {
await this.reader.cancel();
this.reader.releaseLock();
this.reader = null;
}
if (this.writer) {
await this.writer.releaseLock();
this.writer = null;
}
if (this.port) {
this.port.removeEventListener("disconnect", this.handleDisconnect);
await this.port.close();
this.port = null;
}
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.closeRequested = false;
this.dispatchEvent(new CustomEvent("disconnect", { detail: true }));
if (callback) {
try {
callback(true);
} catch (error) {
console.error(`${this.logHead} Error calling disconnect callback:`, error);
}
}
return true;
} catch (error) {
console.error(`${this.logHead} Error disconnecting:`, error);
console.error(
`${this.logHead} Failed to close connection with ID: ${this.connectionId} closed, Sent: ${this.bytesSent} bytes, Received: ${this.bytesReceived} bytes`,
);
this.closeRequested = false;
this.dispatchEvent(new CustomEvent("disconnect", { detail: false }));
if (callback) {
try {
callback(false);
} catch (error) {
console.error(`${this.logHead} Error calling disconnect callback:`, error);
}
}
return false;
} finally {
if (this.openCanceled) {
this.openCanceled = false;
}
}
}
async send(data, callback) {
if (!this.connected || !this.writer) {
console.error(`${this.logHead} Failed to send data, serial port not open`);
if (callback) {
callback({ bytesSent: 0 });
}
return { bytesSent: 0 };
}
try {
await this.writer.write(data);
this.bytesSent += data.byteLength;
const result = { bytesSent: data.byteLength };
if (callback) {
callback(result);
}
return result;
} catch (error) {
console.error(`${this.logHead} Error sending data:`, error);
if (callback) {
callback({ bytesSent: 0 });
}
return { bytesSent: 0 };
}
}
/**
* Clean up resources when the protocol is no longer needed
*/
cleanup() {
if (this.connected) {
this.disconnect();
}
}
}
// Export the class itself, not an instance
export default WebSerial;

View file

@ -1,9 +1,7 @@
class WebsocketSerial extends EventTarget {
class Websocket extends EventTarget {
constructor() {
super();
this.logHead = "[WEBSOCKET]";
this.connected = false;
this.connectionInfo = null;
@ -12,6 +10,8 @@ class WebsocketSerial extends EventTarget {
this.bytesReceived = 0;
this.failed = 0;
this.logHead = "[WEBSOCKET]";
this.address = "ws://localhost:5761";
this.ws = null;
@ -123,4 +123,4 @@ class WebsocketSerial extends EventTarget {
}
}
export default new WebsocketSerial();
export default Websocket;

View file

@ -0,0 +1,78 @@
export const bluetoothDevices = [
{
name: "CC2541",
serviceUuid: "0000ffe0-0000-1000-8000-00805f9b34fb",
writeCharacteristic: "0000ffe1-0000-1000-8000-00805f9b34fb",
readCharacteristic: "0000ffe1-0000-1000-8000-00805f9b34fb",
},
{
name: "HC-05",
serviceUuid: "00001101-0000-1000-8000-00805f9b34fb",
writeCharacteristic: "00001101-0000-1000-8000-00805f9b34fb",
readCharacteristic: "00001101-0000-1000-8000-00805f9b34fb",
},
{
name: "HM-10",
serviceUuid: "0000ffe1-0000-1000-8000-00805f9b34fb",
writeCharacteristic: "0000ffe1-0000-1000-8000-00805f9b34fb",
readCharacteristic: "0000ffe1-0000-1000-8000-00805f9b34fb",
},
{
name: "HM-11",
serviceUuid: "6e400001-b5a3-f393-e0a9-e50e24dcca9e",
writeCharacteristic: "6e400003-b5a3-f393-e0a9-e50e24dcca9e",
readCharacteristic: "6e400002-b5a3-f393-e0a9-e50e24dcca9e",
},
{
name: "Nordic NRF",
serviceUuid: "6e400001-b5a3-f393-e0a9-e50e24dcca9e",
writeCharacteristic: "6e400003-b5a3-f393-e0a9-e50e24dcca9e",
readCharacteristic: "6e400002-b5a3-f393-e0a9-e50e24dcca9e",
},
{
name: "SpeedyBee V1",
serviceUuid: "00001000-0000-1000-8000-00805f9b34fb",
writeCharacteristic: "00001001-0000-1000-8000-00805f9b34fb",
readCharacteristic: "00001002-0000-1000-8000-00805f9b34fb",
},
{
name: "SpeedyBee V2",
serviceUuid: "0000abf0-0000-1000-8000-00805f9b34fb",
writeCharacteristic: "0000abf1-0000-1000-8000-00805f9b34fb",
readCharacteristic: "0000abf2-0000-1000-8000-00805f9b34fb",
},
];
export const serialDevices = [
{ vendorId: 1027, productId: 24577 }, // FT232R USB UART
{ vendorId: 1155, productId: 12886 }, // STM32 in HID mode
{ vendorId: 1155, productId: 14158 }, // 0483:374e STM Electronics STLink Virtual COM Port (NUCLEO boards)
{ vendorId: 1155, productId: 22336 }, // STM Electronics Virtual COM Port
{ vendorId: 4292, productId: 60000 }, // CP210x
{ vendorId: 4292, productId: 60001 }, // CP210x
{ vendorId: 4292, productId: 60002 }, // CP210x
{ vendorId: 11836, productId: 22336 }, // AT32 VCP
{ vendorId: 12619, productId: 22336 }, // APM32 VCP
];
export const usbDevices = {
filters: [
{ vendorId: 1155, productId: 57105 }, // STM Device in DFU Mode || Digital Radio in USB mode
{ vendorId: 10473, productId: 393 }, // GD32 DFU Bootloader
{ vendorId: 0x2e3c, productId: 0xdf11 }, // AT32F435 DFU Bootloader
{ vendorId: 12619, productId: 262 }, // APM32 DFU Bootloader
],
};
export const vendorIdNames = {
1027: "FTDI",
1155: "STM Electronics",
4292: "Silicon Labs",
0x2e3c: "AT32",
0x314b: "Geehy Semiconductor",
};
export const webSerialDevices = serialDevices.map(({ vendorId, productId }) => ({
usbVendorId: vendorId,
usbProductId: productId,
}));

View file

@ -15,7 +15,7 @@ import { gui_log } from "../gui_log";
import MSPCodes from "../msp/MSPCodes";
import PortUsage from "../port_usage";
import $ from "jquery";
import serial from "../webSerial";
import { serial } from "../serial";
import DFU from "../protocols/webusbdfu";
import { read_serial } from "../serial_backend";
import NotificationManager from "../utils/notifications";

View file

@ -16,7 +16,7 @@
import GUI, { TABS } from "../gui";
import { i18n } from "../localization";
import { gui_log } from "../gui_log";
import { usbDevices } from "../usb_devices";
import { usbDevices } from "./devices";
import NotificationManager from "../utils/notifications";
import { get as getConfig } from "../ConfigStorage";

290
src/js/serial.js Normal file
View file

@ -0,0 +1,290 @@
import CONFIGURATOR from "./data_storage";
import WebSerial from "./protocols/WebSerial.js";
import WebBluetooth from "./protocols/WebBluetooth.js";
import Websocket from "./protocols/WebSocket.js";
import VirtualSerial from "./protocols/VirtualSerial.js";
/**
* Base Serial class that manages all protocol implementations
* and handles event forwarding.
*/
class Serial extends EventTarget {
constructor() {
super();
this._protocol = null;
this._eventHandlers = {};
this.logHead = "[SERIAL]";
// Initialize the available protocols
this._webSerial = new WebSerial();
this._bluetooth = new WebBluetooth();
this._websocket = new Websocket();
this._virtual = new VirtualSerial();
// Initialize with default protocol
this.selectProtocol(false);
// Forward events from all protocols to the Serial class
this._setupEventForwarding();
}
/**
* Set up event forwarding from all protocols to the Serial class
*/
_setupEventForwarding() {
const protocols = [this._webSerial, this._bluetooth, this._websocket, this._virtual];
const events = ["addedDevice", "removedDevice", "connect", "disconnect", "receive"];
protocols.forEach((protocol) => {
if (protocol && typeof protocol.addEventListener === "function") {
events.forEach((eventType) => {
protocol.addEventListener(eventType, (event) => {
let newDetail;
// Special handling for 'receive' events to ensure data is properly passed through
if (eventType === "receive") {
// If it's already a Uint8Array or ArrayBuffer, keep it as is
newDetail = {
data: event.detail,
protocolType:
protocol === this._webSerial
? "webSerial"
: protocol === this._bluetooth
? "bluetooth"
: protocol === this._websocket
? "websocket"
: protocol === this._virtual
? "virtual"
: "unknown",
};
} else {
// For all other events, pass through the detail as is
newDetail = event.detail;
}
// Dispatch the event with the new detail
this.dispatchEvent(
new CustomEvent(event.type, {
detail: newDetail,
bubbles: event.bubbles,
cancelable: event.cancelable,
}),
);
});
});
}
});
}
/**
* Selects the appropriate protocol based on port path or CONFIGURATOR settings
* @param {string|null} portPath - Optional port path to determine protocol
* @param {boolean} forceDisconnect - Whether to force disconnect from current protocol
*/
selectProtocol(portPath = null, forceDisconnect = true) {
// Determine which protocol to use based on port path first, then fall back to CONFIGURATOR
let newProtocol;
if (portPath) {
// Select protocol based on port path
if (portPath === "virtual") {
console.log(`${this.logHead} Using virtual protocol (based on port path)`);
newProtocol = this._virtual;
// Update CONFIGURATOR flags for consistency
CONFIGURATOR.virtualMode = true;
CONFIGURATOR.bluetoothMode = false;
CONFIGURATOR.manualMode = false;
} else if (portPath === "manual") {
console.log(`${this.logHead} Using websocket protocol (based on port path)`);
newProtocol = this._websocket;
// Update CONFIGURATOR flags for consistency
CONFIGURATOR.virtualMode = false;
CONFIGURATOR.bluetoothMode = false;
CONFIGURATOR.manualMode = true;
} else if (portPath.startsWith("bluetooth")) {
console.log(`${this.logHead} Using bluetooth protocol (based on port path)`);
newProtocol = this._bluetooth;
// Update CONFIGURATOR flags for consistency
CONFIGURATOR.virtualMode = false;
CONFIGURATOR.bluetoothMode = true;
CONFIGURATOR.manualMode = false;
} else {
console.log(`${this.logHead} Using web serial protocol (based on port path)`);
newProtocol = this._webSerial;
// Update CONFIGURATOR flags for consistency
CONFIGURATOR.virtualMode = false;
CONFIGURATOR.bluetoothMode = false;
CONFIGURATOR.manualMode = false;
}
} else {
// Fall back to CONFIGURATOR flags if no port path is provided
if (CONFIGURATOR.virtualMode) {
console.log(`${this.logHead} Using virtual protocol (based on CONFIGURATOR flags)`);
newProtocol = this._virtual;
} else if (CONFIGURATOR.manualMode) {
console.log(`${this.logHead} Using websocket protocol (based on CONFIGURATOR flags)`);
newProtocol = this._websocket;
} else if (CONFIGURATOR.bluetoothMode) {
console.log(`${this.logHead} Using bluetooth protocol (based on CONFIGURATOR flags)`);
newProtocol = this._bluetooth;
} else {
console.log(`${this.logHead} Using web serial protocol (based on CONFIGURATOR flags)`);
newProtocol = this._webSerial;
}
}
// If we're switching to a different protocol
if (this._protocol !== newProtocol) {
// Clean up previous protocol if exists
if (this._protocol && forceDisconnect) {
// Disconnect if connected
if (this._protocol.connected) {
console.log(`${this.logHead} Disconnecting from current protocol before switching`);
this._protocol.disconnect();
}
}
// Set new protocol
this._protocol = newProtocol;
console.log(`${this.logHead} Protocol switched successfully to:`, this._protocol);
} else {
console.log(`${this.logHead} Same protocol selected, no switch needed`);
}
return this._protocol;
}
/**
* Connect to the specified port with options
* @param {string|function} path - Port path or callback for virtual mode
* @param {object} options - Connection options (baudRate, etc.)
*/
connect(path, options) {
if (!this._protocol) {
console.error(`${this.logHead} No protocol selected, cannot connect`);
return false;
}
// If path is a function, it's a callback for virtual mode
const isCallback = typeof path === "function";
// In virtual mode, a callback is passed as the first parameter
if (isCallback && CONFIGURATOR.virtualMode) {
console.log(`${this.logHead} Connecting in virtual mode`);
return this._protocol.connect(path);
}
// Check if already connected
if (this._protocol.connected) {
console.warn(`${this.logHead} Protocol already connected, not connecting again`);
// If we're already connected to the requested port, return success
const connectedPort = this._protocol.getConnectedPort?.();
if (connectedPort && connectedPort.path === path) {
console.log(`${this.logHead} Already connected to the requested port`);
return true;
}
// If we're connected to a different port, disconnect first
console.log(`${this.logHead} Connected to a different port, disconnecting first`);
this.disconnect((success) => {
if (success) {
// Now connect to the new port
console.log(`${this.logHead} Reconnecting to new port:`, path);
this._protocol.connect(path, options);
} else {
console.error(`${this.logHead} Failed to disconnect before reconnecting`);
}
});
return true;
}
console.log(`${this.logHead} Connecting to port:`, path, "with options:", options);
return this._protocol.connect(path, options);
}
/**
* Disconnect from the current connection
*/
disconnect(callback) {
if (!this._protocol) {
console.warn(`${this.logHead} No protocol selected, nothing to disconnect`);
if (callback) callback(false);
return false;
}
if (!this._protocol.connected) {
console.warn(`${this.logHead} Protocol not connected, nothing to disconnect`);
if (callback) callback(false);
return false;
}
console.log(`${this.logHead} Disconnecting from current protocol`);
try {
// Disconnect from the protocol
const result = this._protocol.disconnect((success) => {
if (success) {
// Ensure our connection state is updated
console.log(`${this.logHead} Disconnection successful`);
} else {
console.error(`${this.logHead} Disconnection failed`);
}
// Call the callback with the result
if (callback) callback(success);
});
return result;
} catch (error) {
console.error(`${this.logHead} Error during disconnect:`, error);
if (callback) callback(false);
return false;
}
}
/**
* Send data through the serial connection
*/
send(data, callback) {
if (!this._protocol || !this._protocol.connected) {
console.warn(`${this.logHead} Cannot send data - not connected`);
if (callback) callback({ bytesSent: 0 });
return { bytesSent: 0 };
}
return this._protocol.send(data, callback);
}
/**
* Get devices from the current protocol
*/
async getDevices() {
return this._protocol?.getDevices() || [];
}
/**
* Request permission for a device
*/
async requestPermissionDevice(showAllSerialDevices = false) {
return this._protocol?.requestPermissionDevice(showAllSerialDevices) || null;
}
/**
* Get the currently connected port
*/
getConnectedPort() {
return this._protocol?.getConnectedPort() || null;
}
/**
* Get connection status
*/
get connected() {
return this._protocol ? this._protocol.connected : false;
}
}
// Export a singleton instance
export const serial = new Serial();

View file

@ -23,14 +23,12 @@ import CryptoES from "crypto-es";
import $ from "jquery";
import BuildApi from "./BuildApi";
import { serialShim } from "./serial_shim.js";
import { serial } from "./serial.js";
import { EventBus } from "../components/eventBus";
import { ispConnected } from "./utils/connection";
const logHead = "[SERIAL-BACKEND]";
let serial = serialShim();
let mspHelper;
let connectionTimestamp;
let liveDataRefreshTimerId = false;
@ -80,7 +78,7 @@ export function initializeSerialBackend() {
}
});
// Using serialShim for serial and bluetooth we don't know which event we need before we connect
// Using serial and bluetooth we don't know which event we need before we connect
// Perhaps we should implement a Connection class that handles the connection and events for bluetooth, serial and sockets
// TODO: use event gattserverdisconnected for save and reboot and device removal.
@ -118,39 +116,32 @@ function connectDisconnect() {
PortHandler.portPickerDisabled = true;
$("div.connection_button__label").text(i18n.getMessage("connecting"));
// Set configuration flags for consistency with other code
CONFIGURATOR.virtualMode = selectedPort === "virtual";
CONFIGURATOR.bluetoothMode = selectedPort.startsWith("bluetooth");
CONFIGURATOR.manualMode = selectedPort === "manual";
// Select the appropriate protocol based directly on the port path
serial.selectProtocol(selectedPort);
console.log("Serial protocol selected:", serial._protocol, "using port", portName);
if (CONFIGURATOR.virtualMode) {
CONFIGURATOR.virtualApiVersion = PortHandler.portPicker.virtualMspVersion;
// Hack to get virtual working on the web
serial = serialShim();
// Virtual mode uses a callback instead of port path
serial.connect(onOpenVirtual);
} else if (selectedPort === "manual") {
serial = serialShim();
// Explicitly disconnect the event listeners before attaching the new ones.
serial.removeEventListener("connect", connectHandler);
serial.addEventListener("connect", connectHandler);
serial.removeEventListener("disconnect", disconnectHandler);
serial.addEventListener("disconnect", disconnectHandler);
serial.connect(portName, { baudRate });
} else {
CONFIGURATOR.virtualMode = false;
serial = serialShim();
// Explicitly disconnect the event listeners before attaching the new ones.
// Set up event listeners for all non-virtual connections
serial.removeEventListener("connect", connectHandler);
serial.addEventListener("connect", connectHandler);
serial.removeEventListener("disconnect", disconnectHandler);
serial.addEventListener("disconnect", disconnectHandler);
// All non-virtual modes pass the port path and options
serial.connect(portName, { baudRate });
}
} else {
// If connected, start disconnection sequence
GUI.timeout_kill_all();
GUI.interval_kill_all();
GUI.tab_switch_cleanup(() => (GUI.tab_switch_in_progress = false));
@ -165,7 +156,11 @@ function connectDisconnect() {
// show CLI panel on Control+I
document.onkeydown = function (e) {
if (e.code === "KeyI" && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
if (isConnected && GUI.active_tab !== "cli" && semver.gte(FC.CONFIG.apiVersion, API_VERSION_1_47)) {
if (
serial.connected &&
GUI.active_tab !== "cli" &&
semver.gte(FC.CONFIG.apiVersion, API_VERSION_1_47)
) {
GUI.showCliPanel();
}
}
@ -184,7 +179,6 @@ function finishClose(finishedCallback) {
$("#dialogResetToCustomDefaults")[0].close();
}
// serialShim calls the disconnect method for selected connection type.
serial.disconnect(onClosed);
MSP.disconnect_cleanup();
@ -249,8 +243,6 @@ function resetConnection() {
// unlock port select & baud
PortHandler.portPickerDisabled = false;
// reset data
isConnected = false;
}
function abortConnection() {
@ -271,7 +263,7 @@ function abortConnection() {
* when serial events are handled.
*/
function read_serial_adapter(event) {
read_serial(event.detail.buffer);
read_serial(event.detail.data);
}
function onOpen(openInfo) {
@ -290,7 +282,7 @@ function onOpen(openInfo) {
const result = getConfig("expertMode")?.expertMode ?? false;
$('input[name="expertModeCheckbox"]').prop("checked", result).trigger("change");
// serialShim adds event listener for selected connection type
// serial adds event listener for selected connection type
serial.removeEventListener("receive", read_serial_adapter);
serial.addEventListener("receive", read_serial_adapter);

View file

@ -1,24 +0,0 @@
export const vendorIdNames = {
1027: "FTDI",
1155: "STM Electronics",
4292: "Silicon Labs",
0x2e3c: "AT32",
0x314b: "Geehy Semiconductor",
};
export const serialDevices = [
{ vendorId: 1027, productId: 24577 }, // FT232R USB UART
{ vendorId: 1155, productId: 12886 }, // STM32 in HID mode
{ vendorId: 1155, productId: 14158 }, // 0483:374e STM Electronics STLink Virtual COM Port (NUCLEO boards)
{ vendorId: 1155, productId: 22336 }, // STM Electronics Virtual COM Port
{ vendorId: 4292, productId: 60000 }, // CP210x
{ vendorId: 4292, productId: 60001 }, // CP210x
{ vendorId: 4292, productId: 60002 }, // CP210x
{ vendorId: 11836, productId: 22336 }, // AT32 VCP
{ vendorId: 12619, productId: 22336 }, // APM32 VCP
];
export const webSerialDevices = serialDevices.map(({ vendorId, productId }) => ({
usbVendorId: vendorId,
usbProductId: productId,
}));

View file

@ -1,18 +0,0 @@
import CONFIGURATOR from "./data_storage";
import serialWeb from "./webSerial.js";
import BT from "./protocols/bluetooth.js";
import websocketSerial from "./protocols/websocket.js";
import virtualSerial from "./virtualSerial.js";
export const serialShim = () => {
if (CONFIGURATOR.virtualMode) {
return virtualSerial;
}
if (CONFIGURATOR.manualMode) {
return websocketSerial;
}
if (CONFIGURATOR.bluetoothMode) {
return BT;
}
return serialWeb;
};

View file

@ -10,12 +10,10 @@ import CliAutoComplete from "../CliAutoComplete";
import { gui_log } from "../gui_log";
import jBox from "jbox";
import $ from "jquery";
import { serialShim } from "../serial_shim";
import { serial } from "../serial";
import FileSystem from "../FileSystem";
import { ispConnected } from "../utils/connection";
const serial = serialShim();
const cli = {
lineDelayMs: 5,
profileSwitchDelayMs: 100,

View file

@ -1,8 +0,0 @@
export const usbDevices = {
filters: [
{ vendorId: 1155, productId: 57105 }, // STM Device in DFU Mode || Digital Radio in USB mode
{ vendorId: 10473, productId: 393 }, // GD32 DFU Bootloader
{ vendorId: 0x2e3c, productId: 0xdf11 }, // AT32F435 DFU Bootloader
{ vendorId: 12619, productId: 262 }, // APM32 DFU Bootloader
],
};

View file

@ -3,7 +3,7 @@ import FileSystem from "../FileSystem";
import { generateFilename } from "./generate_filename";
import { gui_log } from "../gui_log";
import { i18n } from "../localization";
import serial from "../webSerial";
import { serial } from "../serial";
/**
*
@ -62,7 +62,7 @@ class AutoBackup {
}
readSerialAdapter(info) {
const data = new Uint8Array(info.detail.buffer);
const data = new Uint8Array(info.detail.data);
for (const charCode of data) {
const currentChar = String.fromCharCode(charCode);

View file

@ -8,7 +8,7 @@ import MSP from "../msp";
import MSPCodes from "../msp/MSPCodes";
import semver from "semver";
import { API_VERSION_1_45, API_VERSION_1_46 } from "../data_storage";
import serial from "../webSerial";
import { serial } from "../serial";
/**
*
@ -19,7 +19,7 @@ import serial from "../webSerial";
let mspHelper = null;
function readSerialAdapter(event) {
MSP.read(event.detail.buffer);
MSP.read(event.detail);
}
class AutoDetect {
@ -109,7 +109,7 @@ class AutoDetect {
);
}
serial.disconnect(this.onClosed);
serial.disconnect(this.onClosed.bind(this));
MSP.disconnect_cleanup();
}

View file

@ -1,256 +0,0 @@
import { webSerialDevices, vendorIdNames } from "./serial_devices";
import { checkBrowserCompatibility } from "./utils/checkBrowserCompatibilty";
async function* streamAsyncIterable(reader, keepReadingFlag) {
try {
while (keepReadingFlag()) {
const { done, value } = await reader.read();
if (done) {
return;
}
yield value;
}
} finally {
reader.releaseLock();
}
}
class WebSerial extends EventTarget {
constructor() {
super();
checkBrowserCompatibility();
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.logHead = "[SERIAL] ";
this.portCounter = 0;
this.ports = [];
this.port = null;
this.reader = null;
this.writer = null;
this.reading = false;
this.connect = this.connect.bind(this);
navigator.serial.addEventListener("connect", (e) => this.handleNewDevice(e.target));
navigator.serial.addEventListener("disconnect", (e) => this.handleRemovedDevice(e.target));
this.loadDevices();
}
handleNewDevice(device) {
const added = this.createPort(device);
this.ports.push(added);
this.dispatchEvent(new CustomEvent("addedDevice", { detail: added }));
return added;
}
handleRemovedDevice(device) {
const removed = this.ports.find((port) => port.port === device);
this.ports = this.ports.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.port;
}
createPort(port) {
const displayName = vendorIdNames[port.getInfo().usbVendorId]
? vendorIdNames[port.getInfo().usbVendorId]
: `VID:${port.getInfo().usbVendorId} PID:${port.getInfo().usbProductId}`;
return {
path: `serial_${this.portCounter++}`,
displayName: `Betaflight ${displayName}`,
vendorId: port.getInfo().usbVendorId,
productId: port.getInfo().usbProductId,
port: port,
};
}
async loadDevices() {
const ports = await navigator.serial.getPorts();
this.portCounter = 1;
this.ports = ports.map(function (port) {
return this.createPort(port);
}, this);
}
async requestPermissionDevice(showAllSerialDevices = false) {
let newPermissionPort = null;
try {
const options = showAllSerialDevices ? {} : { filters: webSerialDevices };
const userSelectedPort = await navigator.serial.requestPort(options);
newPermissionPort = this.ports.find((port) => port.port === userSelectedPort);
if (!newPermissionPort) {
newPermissionPort = this.handleNewDevice(userSelectedPort);
}
console.info(`${this.logHead}User selected SERIAL device from permissions:`, newPermissionPort.path);
} catch (error) {
console.error(`${this.logHead}User didn't select any SERIAL device when requesting permission:`, error);
}
return newPermissionPort;
}
async getDevices() {
return this.ports;
}
async connect(path, options) {
this.openRequested = true;
this.closeRequested = false;
this.port = this.ports.find((device) => device.path === path).port;
await this.port.open(options);
const connectionInfo = this.port.getInfo();
this.connectionInfo = connectionInfo;
this.writer = this.port.writable.getWriter();
this.reader = this.port.readable.getReader();
if (connectionInfo && !this.openCanceled) {
this.connected = true;
this.connectionId = path;
this.bitrate = options.baudRate;
this.bytesReceived = 0;
this.bytesSent = 0;
this.failed = 0;
this.openRequested = false;
this.port.addEventListener("disconnect", this.handleDisconnect.bind(this));
this.addEventListener("receive", this.handleReceiveBytes);
console.log(
`${this.logHead} Connection opened with ID: ${connectionInfo.connectionId}, Baud: ${options.baudRate}`,
);
this.dispatchEvent(new CustomEvent("connect", { detail: connectionInfo }));
// Check if we need the helper function or could polyfill
// the stream async iterable interface:
// https://web.dev/streams/#asynchronous-iteration
this.reading = true;
for await (let value of streamAsyncIterable(this.reader, () => this.reading)) {
this.dispatchEvent(new CustomEvent("receive", { detail: value }));
}
} else if (connectionInfo && this.openCanceled) {
this.connectionId = connectionInfo.connectionId;
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 serial port`);
this.dispatchEvent(new CustomEvent("connect", { detail: false }));
}
}
async disconnect() {
this.connected = false;
this.transmitting = false;
this.reading = 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.reader) {
this.reader.cancel();
this.reader.releaseLock();
this.reader = null;
}
if (this.writer) {
await this.writer.releaseLock();
this.writer = null;
}
if (this.port) {
this.port.removeEventListener("disconnect", this.handleDisconnect.bind(this));
await this.port.close();
this.port = 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) {
// TODO: previous serial implementation had a buffer of 100, do we still need it with streams?
if (this.writer) {
await this.writer.write(data);
this.bytesSent += data.byteLength;
} else {
console.error(`${this.logHead}Failed to send data, serial port not open`);
}
return {
bytesSent: data.byteLength,
};
}
}
export default new WebSerial();

View file

@ -3,9 +3,7 @@ import { i18n } from "../../js/localization";
import CONFIGURATOR from "../../js/data_storage";
import { reinitializeConnection } from "../../js/serial_backend";
import { gui_log } from "../../js/gui_log";
import { serialShim } from "../../js/serial_shim";
const serial = serialShim();
import { serial } from "../../js/serial";
export default class CliEngine {
constructor(currentTab) {